우아한 컴포넌트 만들기: React 컴파운드 패턴
2025-06-06
들어가며
개발을 하다 보면 하나의 UI를 구현하기 위해 여러 개의 컴포넌트가 복잡하게 얽히는 경우가 많습니다. 이처럼 UI 구성 요소들이 서로 얽히기 시작하면, 이 구조를 얼마나 깔끔하게 분리하고 유지보수하기 좋게 만들 수 있을지는 늘 고민스럽습니다.
예를 들어, 탭(Tab)과 탭 리스트(Tab List)를 가진 UI를 구현한다고 가정해볼게요.
import { useState } from 'react';
export function BadExample() {
const [tab, setTab] = useState('account');
return (
<div className="tabs">
<div className="tab-buttons">
<div
className={`tab-button ${tab === 'account' ? 'active' : ''}`}
onClick={() => setTab('account')}>
Account
</div>
<div
className={`tab-button ${tab === 'password' ? 'active' : ''}`}
onClick={() => setTab('password')}>
Password
</div>
</div>
<div className="tab-contents">
{tab === 'account' && <div className="tab-content">Account content</div>}
{tab === 'password' && <div className="tab-content">Password content</div>}
</div>
</div>
);
}
처음 이 코드를 보는 개발자라면 각 요소가 어떤 역할을 하는지 파악하는 데 시간이 걸릴 수 있습니다. DOM 구조나 클래스 이름만 봐서는 이 컴포넌트가 어떤 식으로 동작하는지 명확하게 이해하기 어렵기 때문이죠.
반면에 널리 사용되는 UI 라이브러리인 shadcn/ui를 사용하다 보면 다음과 같은 구조의 컴포넌트를 자주 보게 됩니다.
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
export function Example() {
return (
<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">Account content</TabsContent>
<TabsContent value="password">Password content</TabsContent>
</Tabs>
);
}
이 코드는 훨씬 구조화되어 있고, 각 컴포넌트의 역할도 명확하게 드러납니다. 어떤 요소가 탭 트리거인지, 어떤 요소가 콘텐츠를 나타내는지 이름만 봐도 바로 이해할 수 있죠.
이처럼 여러 컴포넌트가 상위 컴포넌트의 상태나 컨텍스트를 공유하면서 긴밀하게 협력해 동작하는 구조를 **컴파운드 컴포넌트 패턴(Compound Component Pattern)**이라고 부릅니다.
이번 글에서는 이 패턴을 활용해 아코디언(Accordion) 컴포넌트를 직접 구현해보겠습니다.
컴파운드 컴포넌트 패턴이란?
웹 개발에서는 서로 상태를 공유하며 동작해야 하는 컴포넌트들이 많습니다. 예를 들어, 탭, 아코디언, 드롭다운 등에서는 각 구성 요소들이 서로를 인식하고 함께 작동해야 하죠.
컴파운드 컴포넌트 패턴은 이런 복잡한 상호작용을 우아하게 해결할 수 있는 React 디자인 패턴입니다.
HTML의 <select>
와 <option>
처럼 자연스럽고 선언적인 API를 만들 수 있다는 것이 큰 장점입니다.
실전 예제: FAQ 아코디언 만들기
1단계: Context API로 상태 관리하기
아코디언 컴포넌트는 여러 개의 아이템으로 구성되며, 이들끼리 하나의 상태(activeItem)를 공유해야 합니다. 사용자가 특정 아이템을 클릭했을 때 해당 항목을 열거나 닫는 동작을 처리하려면, 상태와 토글 함수를 공통으로 관리할 필요가 있죠.
이때 React의 Context API를 활용하면 하위 컴포넌트들에게 필요한 상태를 쉽게 전달할 수 있습니다.
import React, { createContext, useContext, useState } from 'react';
const AccordionContext = createContext();
function Accordion({ children, defaultValue = null }) {
const [activeItem, setActiveItem] = useState(defaultValue);
const toggleItem = (value) => {
setActiveItem(activeItem === value ? null : value);
};
const contextValue = { activeItem, toggleItem };
return (
<AccordionContext.Provider value={contextValue}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
);
}
위 코드에서는 AccordionContext를 통해 현재 열려 있는 항목(activeItem)과 항목을 토글하는 함수(toggleItem)를 하위 컴포넌트에 전달합니다. 이제 하위 컴포넌트들은 별도로 상태를 관리하지 않고도, Context에서 값을 가져와서 필요한 동작을 수행할 수 있게 됩니다.
2단계: 아코디언 아이템 구성하기
이제 실제 아코디언의 구조를 만들어봅니다. 아코디언은 각 항목마다 질문과 답변이 쌍으로 구성됩니다. 이를 위해 AccordionItem, AccordionTrigger, AccordionContent라는 세 가지 컴포넌트를 만들었습니다.
AccordionItem
AccordionItem은 하나의 아코디언 항목을 감싸는 래퍼입니다. value 속성을 통해 각각의 항목이 고유하게 식별됩니다.
function AccordionItem({ children, value }) {
return (
<div className="accordion-item" data-value={value}>
{children}
</div>
);
}
AccordionTrigger
AccordionTrigger는 클릭 가능한 버튼 역할을 합니다. 이 버튼을 클릭하면 해당 항목이 열리거나 닫히고, 어떤 항목이 활성 상태인지에 따라 스타일이 달라집니다.
function AccordionTrigger({ children, value }) {
const { activeItem, toggleItem } = useContext(AccordionContext);
const isActive = activeItem === value;
return (
<button
className={`accordion-trigger ${isActive ? 'active' : ''}`}
onClick={() => toggleItem(value)}
aria-expanded={isActive}>
<span className="accordion-title">{children}</span>
<span className={`accordion-icon ${isActive ? 'rotated' : ''}`}>▼</span>
</button>
);
}
-
isActive가 true이면 해당 항목이 현재 열려 있는 상태입니다.
-
aria-expanded 속성은 접근성을 고려한 부분으로, 현재 열림 여부를 스크린 리더 등에 알려줍니다.
-
아이콘 회전 등도 isActive에 따라 제어할 수 있습니다.
AccordionContent
AccordionContent는 실제로 열릴 콘텐츠 영역입니다. 현재 활성화된 항목(activeItem)과 value를 비교하여 보여줄지 여부를 판단합니다.
function AccordionContent({ children, value }) {
const { activeItem } = useContext(AccordionContext);
const isActive = activeItem === value;
return (
<div className={`accordion-content ${isActive ? 'expanded' : 'collapsed'}`}>
<div className="accordion-content-inner">{children}</div>
</div>
);
}
-
콘텐츠가 열릴 때와 닫힐 때 다른 클래스를 적용해 애니메이션 등을 줄 수 있습니다.
-
내부에 별도의 wrapper(accordion-content-inner)를 둠으로써 레이아웃을 안정적으로 구성할 수 있습니다.
마지막으로, 위에서 만든 세 컴포넌트를 Accordion 컴포넌트에 속성처럼 연결해두면 다음과 같이 사용할 수 있게 됩니다:
Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;
이렇게 하면 <Accordion.Item>, <Accordion.Trigger>, <Accordion.Content>와 같은 방식으로 깔끔하게 사용할 수 있습니다. 구조를 명확하게 드러내고, 유지보수나 재사용성도 좋아지죠.
3단계: 완성된 아코디언 사용 예시
이제 앞서 만든 Accordion 컴포넌트를 실제로 사용하는 예제를 살펴보겠습니다. FAQ 페이지처럼 여러 질문과 답변을 나열할 때 유용하게 쓸 수 있습니다.
각 질문은 Accordion.Item으로 감싸고, 내부에는 Accordion.Trigger와 Accordion.Content로 구성합니다.
export default function FAQPage() {
return (
<div className="faq-container">
<h1>자주 묻는 질문</h1>
<Accordion defaultValue="shipping">
<Accordion.Item value="shipping">
<Accordion.Trigger value="shipping">🚚 배송은 얼마나 걸리나요?</Accordion.Trigger>
<Accordion.Content value="shipping">
일반 배송은 2-3일, 익일배송은 다음날 오후 6시까지 도착합니다. 제주도 및 도서산간 지역은
1-2일 추가 소요될 수 있습니다.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="return">
<Accordion.Trigger value="return">🔄 교환/환불은 어떻게 하나요?</Accordion.Trigger>
<Accordion.Content value="return">
상품 수령 후 7일 이내 마이페이지에서 교환/환불 신청이 가능합니다. 단순 변심의 경우
배송비는 고객 부담입니다.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="size">
<Accordion.Trigger value="size">📏 사이즈가 맞지 않으면 어떻게 하나요?</Accordion.Trigger>
<Accordion.Content value="size">
사이즈 불만족 시 무료 교환 서비스를 제공합니다. 상품 페이지의 사이즈 가이드를 꼭
확인해주세요.
</Accordion.Content>
</Accordion.Item>
</Accordion>
</div>
);
}
defaultValue를 설정하면 페이지 진입 시 기본으로 열릴 항목을 지정할 수 있습니다.
각 항목은 value 속성으로 고유하게 구분되며, 토글 시 이 값을 기준으로 상태가 변경됩니다.
React.Children을 활용한 대안
Context API 없이도 비슷한 구조를 만들 수 있습니다. React.cloneElement를 사용하면 부모 컴포넌트가 자식에게 직접 prop을 주입할 수 있기 때문이죠.
이 방식은 간단한 구조에서는 유용하지만, 약간의 제약이 있습니다
Accordion 컴포넌트
function Accordion({ children, defaultValue = null }) {
const [activeItem, setActiveItem] = useState(defaultValue);
const toggleItem = (value) => {
setActiveItem(activeItem === value ? null : value);
};
return (
<div className="accordion">
{React.Children.map(children, (child) =>
React.cloneElement(child, { activeItem, toggleItem })
)}
</div>
);
}
AccordionItem 컴포넌트
AccordionItem은 자식 컴포넌트에 필요한 prop을 전달하며 계층을 유지합니다.
function AccordionItem({ children, value, activeItem, toggleItem }) {
return (
<div className="accordion-item">
{React.Children.map(children, (child) =>
React.cloneElement(child, {
value,
activeItem,
toggleItem,
isActive: activeItem === value,
})
)}
</div>
);
}
1. React.Children 사용 시 구조 제한
- 구조 제한 React.Children 방식은 JSX 구조가 변경되면 동작하지 않을 수 있습니다.
// ❌ 작동하지 않음
<Accordion>
<div>
<Accordion.Item value="test">
<Accordion.Trigger value="test">질문</Accordion.Trigger>
</Accordion.Item>
</div>
</Accordion>
// ✅ 작동함
<Accordion>
<Accordion.Item value="test">
<Accordion.Trigger value="test">질문</Accordion.Trigger>
</Accordion.Item>
</Accordion>
중간에 <div>처럼 React가 예상하지 못하는 요소가 끼어들면 prop 주입이 제대로 되지 않습니다.
2. Props 충돌 주의
cloneElement로 주입한 props가 자식 컴포넌트의 기존 props와 충돌할 수 있습니다. 예를 들어 value, onClick 등 중복되는 prop은 의도치 않은 동작을 유발할 수 있습니다.
3. TypeScript 통합 시 복잡성
컴파운드 컴포넌트 패턴은 구조적으로 유연하지만, TypeScript와 함께 사용할 경우 타입 추론이 복잡해질 수 있습니다. 이럴 때는 명시적인 제네릭 타입이나 prop 타입 선언을 별도로 해주는 것이 좋습니다.
컴파운드 패턴은 타입 추론이 어려워지기 때문에 별도의 타입 관리 전략이 필요합니다.
- 디버깅을 쉽게 하기 위해
displayName
설정:
AccordionItem.displayName = 'Accordion.Item';
AccordionTrigger.displayName = 'Accordion.Trigger';
AccordionContent.displayName = 'Accordion.Content';
- PropTypes 또는 TypeScript로 prop 검증 추가:
Accordion.propTypes = {
defaultValue: PropTypes.string,
children: PropTypes.node.isRequired,
};
마무리
이번 글에서는 컴파운드 컴포넌트 패턴을 활용해 Context 기반의 Accordion UI를 구현하고, 이를 실제 FAQ 페이지에서 어떻게 사용할 수 있는지 살펴보았습니다.
이 패턴은 UI 라이브러리를 만들거나 복잡한 인터페이스를 구성할 때 매우 유용하게 쓰입니다. 단순히 라이브러리만 사용하는 것에서 나아가, 직접 구조를 설계해보는 경험은 컴포넌트 설계 역량을 키우는 데 큰 도움이 됩니다.
직접 코드를 구현해보고, 자신만의 방식으로 커스터마이징해보세요!